<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
  <meta name="color-scheme" content="light dark">
  <title>🦙 llama.cpp - chat</title>

  <!-- Note: dependencies can de updated using ./deps.sh script -->
  <link href="./deps_daisyui.min.css" rel="stylesheet" type="text/css" />
  <script src="./deps_tailwindcss.js"></script>
  <style type="text/tailwindcss">
    .markdown {
      h1, h2, h3, h4, h5, h6, ul, ol, li { all: revert; }
      pre {
        @apply whitespace-pre-wrap my-4 rounded-lg p-2;
        border: 1px solid currentColor;
      }
      /* TODO: fix markdown table */
    }
    /*
      Note for daisyui: because we're using a subset of daisyui via CDN, many things won't be included
      We can manually add the missing styles from https://cdnjs.cloudflare.com/ajax/libs/daisyui/4.12.14/full.css
    */
    .bg-base-100 {background-color: var(--fallback-b1,oklch(var(--b1)/1))}
    .bg-base-200 {background-color: var(--fallback-b2,oklch(var(--b2)/1))}
    .bg-base-300 {background-color: var(--fallback-b3,oklch(var(--b3)/1))}
    .text-base-content {color: var(--fallback-bc,oklch(var(--bc)/1))}
    .btn-mini {
      @apply cursor-pointer opacity-0 group-hover:opacity-100 hover:shadow-md;
    }
    .chat-screen { max-width: 900px; }
    /* because the default bubble color is quite dark, we will make a custom one using bg-base-300 */
    .chat-bubble-base-300 {
      --tw-bg-opacity: 1;
      --tw-text-opacity: 1;
      @apply bg-base-300 text-base-content;
    }
  </style>
</head>

<body>
  <div id="app" class="flex flex-row opacity-0"> <!-- opacity-0 will be removed on app mounted -->
    <!-- sidebar -->
    <div class="flex flex-col bg-black bg-opacity-5 w-64 py-8 px-4 h-screen overflow-y-auto">
      <h2 class="font-bold mb-4 ml-4">Conversations</h2>

      <!-- list of conversations -->
      <div :class="{
        'btn btn-ghost justify-start': true,
        'btn-active': messages.length === 0,
      }" @click="newConversation">
        + New conversation
      </div>
      <div v-for="conv in conversations" :class="{
        'btn btn-ghost justify-start font-normal': true,
        'btn-active': conv.id === viewingConvId,
      }" @click="setViewingConv(conv.id)">
        <span class="truncate">{{ conv.messages[0].content }}</span>
      </div>
      <div class="text-center text-xs opacity-40 mt-auto mx-4">
        Conversations are saved to browser's localStorage
      </div>
    </div>

    <div class="chat-screen flex flex-col w-screen h-screen px-8 mx-auto">
      <!-- header -->
      <div class="flex flex-row items-center">
        <div class="grow text-2xl font-bold mt-8 mb-6">
          🦙 llama.cpp - chat
        </div>

        <!-- action buttons (top right) -->
        <div class="flex items-center">
          <button v-if="messages.length > 0" class="btn mr-1" @click="deleteConv(viewingConvId)" :disabled="isGenerating">
            <!-- delete conversation button -->
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
              <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/>
              <path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"/>
            </svg>
          </button>

          <button class="btn" @click="showConfigDialog = true" :disabled="isGenerating">
            <!-- edit config button -->
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-gear" viewBox="0 0 16 16">
              <path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492M5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0"/>
              <path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115z"/>
            </svg>
          </button>

          <!-- theme controller is copied from https://daisyui.com/components/theme-controller/ -->
          <div class="dropdown dropdown-end dropdown-bottom">
            <div tabindex="0" role="button" class="btn m-1">
              Theme
              <svg width="12px" height="12px" class="inline-block h-2 w-2 fill-current opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048">
                <path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path>
              </svg>
            </div>
            <ul tabindex="0" class="dropdown-content bg-base-300 rounded-box z-[1] w-52 p-2 shadow-2xl h-80 overflow-y-auto">
              <li>
                <button
                  class="btn btn-sm btn-block w-full btn-ghost justify-start"
                  :class="{ 'btn-active': selectedTheme === 'auto' }"
                  @click="setSelectedTheme('auto')">
                  auto
                </button>
              </li>
              <li v-for="theme in themes">
                <input
                  type="radio"
                  name="theme-dropdown"
                  class="theme-controller btn btn-sm btn-block w-full btn-ghost justify-start"
                  :aria-label="theme"
                  :value="theme"
                  :checked="selectedTheme === theme"
                  @click="setSelectedTheme(theme)" />
              </li>
            </ul>
          </div>
        </div>
      </div>

      <!-- chat messages -->
      <div id="messages-list" class="flex flex-col grow overflow-y-auto">
        <div class="mt-auto flex justify-center">
          <!-- placeholder to shift the message to the bottom -->
           {{ messages.length === 0 ? 'Send a message to start' : '' }}
        </div>
        <div v-for="msg in messages" class="group">
          <div :class="{
            'chat': true,
            'chat-start': msg.role !== 'user',
            'chat-end': msg.role === 'user',
          }">
            <div :class="{
              'chat-bubble markdown': true,
              'chat-bubble-base-300': msg.role !== 'user',
            }">
              <!-- textarea for editing message -->
              <template v-if="editingMsg && editingMsg.id === msg.id">
                <textarea
                  class="textarea textarea-bordered bg-base-100 text-base-content w-96"
                  v-model="msg.content"></textarea>
                <br/>
                <button class="btn btn-ghost mt-2 mr-2" @click="editingMsg = null">Cancel</button>
                <button class="btn mt-2" @click="editUserMsgAndRegenerate(msg)">Submit</button>
              </template>
              <!-- render message as markdown -->
              <vue-markdown v-else :source="msg.content" />
            </div>
          </div>

          <!-- actions for each message -->
          <div :class="{'text-right': msg.role === 'user'}" class="mx-4 mt-2 mb-2">
            <!-- user message -->
            <button v-if="msg.role === 'user'" class="badge btn-mini" @click="editingMsg = msg" :disabled="isGenerating">
              ✍️ Edit
            </button>
            <!-- assistant message -->
            <button v-if="msg.role === 'assistant'" class="badge btn-mini mr-2" @click="regenerateMsg(msg)" :disabled="isGenerating">
              🔄 Regenerate
            </button>
            <button v-if="msg.role === 'assistant'" class="badge btn-mini mr-2" @click="copyMsg(msg)" :disabled="isGenerating">
              📋 Copy
            </button>
          </div>
        </div>

        <!-- pending (ongoing) assistant message -->
        <div id="pending-msg" class="chat chat-start">
          <div v-if="pendingMsg" class="chat-bubble markdown chat-bubble-base-300">
            <span v-if="!pendingMsg.content" class="loading loading-dots loading-md"></span>
            <vue-markdown v-else :source="pendingMsg.content" />
          </div>
        </div>
      </div>

      <!-- chat input -->
      <div class="flex flex-row items-center mt-8 mb-6">
        <textarea
          class="textarea textarea-bordered w-full"
          placeholder="Type a message (Shift+Enter to add a new line)"
          v-model="inputMsg"
          @keydown.enter.exact.prevent="sendMessage"
          @keydown.enter.shift.exact.prevent="inputMsg += '\n'"
          :disabled="isGenerating"
          id="msg-input"
        ></textarea>
        <button v-if="!isGenerating" class="btn btn-primary ml-2" @click="sendMessage" :disabled="inputMsg.length === 0">Send</button>
        <button v-else class="btn btn-neutral ml-2" @click="stopGeneration">Stop</button>
      </div>
    </div>

    <!-- modal for editing config -->
    <dialog class="modal" :class="{'modal-open': showConfigDialog}">
      <div class="modal-box">
        <h3 class="text-lg font-bold mb-6">Settings</h3>
        <div class="h-[calc(90vh-12rem)] overflow-y-auto">
          <p class="opacity-40 mb-6">Settings below are saved in browser's localStorage</p>
          <label class="form-control mb-2">
            <div class="label">System Message</div>
            <textarea class="textarea textarea-bordered h-24" :placeholder="'Default: ' + configDefault.systemMessage" v-model="config.systemMessage"></textarea>
          </label>
          <template v-for="key in ['temperature', 'top_k', 'top_p', 'min_p', 'max_tokens']">
            <label class="input input-bordered flex items-center gap-2 mb-2">
              <b>{{ key }}</b>
              <input type="text" class="grow" :placeholder="'Default: ' + (configDefault[key] || 'none')" v-model="config[key]" />
            </label>
          </template>
          <!-- TODO: add more sampling-related configs, please regroup them into different "collapse" sections -->
          <div class="collapse collapse-arrow bg-base-200 mb-2">
            <input type="checkbox" />
            <div class="collapse-title font-bold">Advanced config</div>
            <div class="collapse-content">
              <label class="form-control mb-2">
                <div class="label inline">Custom JSON config (For more info, refer to <a class="underline" href="https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md" target="_blank" rel="noopener noreferrer">server documentation</a>)</div>
                <textarea class="textarea textarea-bordered h-24" placeholder="Example: { &quot;mirostat&quot;: 1, &quot;min_p&quot;: 0.1 }" v-model="config.custom"></textarea>
              </label>
            </div>
          </div>
        </div>

        <!-- action buttons -->
        <div class="modal-action">
          <button class="btn" @click="resetConfigDialog">Reset to default</button>
          <button class="btn" @click="closeAndDiscardConfigDialog">Close</button>
          <button class="btn btn-primary" @click="closeAndSaveConfigDialog">Save and close</button>
        </div>
      </div>
    </dialog>
  </div>

  <script src="./deps_markdown-it.js"></script>
  <script type="module">
    import { createApp, defineComponent, shallowRef, computed, h } from './deps_vue.esm-browser.js';
    import { llama } from './completion.js';

    const isString = (x) => !!x.toLowerCase;
    const isNumeric = (n) => !isString(n) && !isNaN(n);

    const BASE_URL = localStorage.getItem('base') // for debugging
      || (new URL('.', document.baseURI).href).toString(); // for production
    const CONFIG_DEFAULT = {
      // Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value.
      apiKey: '',
      systemMessage: 'You are a helpful assistant.',
      // make sure these default values are in sync with `common.h`
      temperature: 0.8,
      top_k: 40,
      top_p: 0.95,
      min_p: 0.05,
      max_tokens: -1,
      custom: '', // custom json-stringified object
    };
    // config keys having numeric value (i.e. temperature, top_k, top_p, etc)
    const CONFIG_NUMERIC_KEYS = Object.entries(CONFIG_DEFAULT).filter(e => isNumeric(e[1])).map(e => e[0]);
    // list of themes supported by daisyui
    const THEMES = ['light', 'dark', 'cupcake', 'bumblebee', 'emerald', 'corporate', 'synthwave', 'retro', 'cyberpunk', 'valentine', 'halloween', 'garden', 'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black', 'luxury', 'dracula', 'cmyk', 'autumn', 'business', 'acid', 'lemonade', 'night', 'coffee', 'winter', 'dim', 'nord', 'sunset'];

    // markdown support
    const VueMarkdown = defineComponent(
      (props) => {
        const md = shallowRef(new markdownit(props.options ?? { breaks: true }));
        for (const plugin of props.plugins ?? []) {
          md.value.use(plugin);
        }
        const content = computed(() => md.value.render(props.source));
        return () => h("div", { innerHTML: content.value });
      },
      { props: ["source", "options", "plugins"] }
    );

    // coversations is stored in localStorage
    // format: { [convId]: { id: string, lastModified: number, messages: [...] } }
    // convId is a string prefixed with 'conv-'
    const StorageUtils = {
      // manage conversations
      getAllConversations() {
        const res = [];
        for (const key in localStorage) {
          if (key.startsWith('conv-')) {
            res.push(JSON.parse(localStorage.getItem(key)));
          }
        }
        res.sort((a, b) => b.lastModified - a.lastModified);
        return res;
      },
      // can return null if convId does not exist
      getOneConversation(convId) {
        return JSON.parse(localStorage.getItem(convId) || 'null');
      },
      // if convId does not exist, create one
      appendMsg(convId, msg) {
        if (msg.content === null) return;
        const conv = StorageUtils.getOneConversation(convId) || {
          id: convId,
          lastModified: Date.now(),
          messages: [],
        };
        conv.messages.push(msg);
        conv.lastModified = Date.now();
        localStorage.setItem(convId, JSON.stringify(conv));
      },
      getNewConvId() {
        return `conv-${Date.now()}`;
      },
      remove(convId) {
        localStorage.removeItem(convId);
      },
      filterAndKeepMsgs(convId, predicate) {
        const conv = StorageUtils.getOneConversation(convId);
        if (!conv) return;
        conv.messages = conv.messages.filter(predicate);
        conv.lastModified = Date.now();
        localStorage.setItem(convId, JSON.stringify(conv));
      },
      popMsg(convId) {
        const conv = StorageUtils.getOneConversation(convId);
        if (!conv) return;
        const msg = conv.messages.pop();
        conv.lastModified = Date.now();
        localStorage.setItem(convId, JSON.stringify(conv));
        return msg;
      },

      // manage config
      getConfig() {
        const savedVal = JSON.parse(localStorage.getItem('config') || '{}');
        // to prevent breaking changes in the future, we always provide default value for missing keys
        return {
          ...CONFIG_DEFAULT,
          ...savedVal,
        };
      },
      setConfig(config) {
        localStorage.setItem('config', JSON.stringify(config));
      },
      getTheme() {
        return localStorage.getItem('theme') || 'auto';
      },
      setTheme(theme) {
        if (theme === 'auto') {
          localStorage.removeItem('theme');
        } else {
          localStorage.setItem('theme', theme);
        }
      },
    };

    // scroll to bottom of chat messages
    // if requiresNearBottom is true, only auto-scroll if user is near bottom
    const chatScrollToBottom = (requiresNearBottom) => {
      const msgListElem = document.getElementById('messages-list');
      const spaceToBottom = msgListElem.scrollHeight - msgListElem.scrollTop - msgListElem.clientHeight;
      if (!requiresNearBottom || (spaceToBottom < 100)) {
        setTimeout(() => msgListElem.scrollTo({ top: msgListElem.scrollHeight }), 1);
      }
    };

    const mainApp = createApp({
      components: {
        VueMarkdown,
      },
      data() {
        return {
          conversations: StorageUtils.getAllConversations(),
          messages: [], // { id: number, role: 'user' | 'assistant', content: string }
          viewingConvId: StorageUtils.getNewConvId(),
          inputMsg: '',
          isGenerating: false,
          pendingMsg: null, // the on-going message from assistant
          stopGeneration: () => {},
          selectedTheme: StorageUtils.getTheme(),
          config: StorageUtils.getConfig(),
          showConfigDialog: false,
          editingMsg: null,
          // const
          themes: THEMES,
          configDefault: {...CONFIG_DEFAULT},
        }
      },
      computed: {},
      mounted() {
        document.getElementById('app').classList.remove('opacity-0'); // show app
        // scroll to the bottom when the pending message height is updated
        const pendingMsgElem = document.getElementById('pending-msg');
        const resizeObserver = new ResizeObserver(() => {
          if (this.isGenerating) chatScrollToBottom(true);
        });
        resizeObserver.observe(pendingMsgElem);
      },
      methods: {
        setSelectedTheme(theme) {
          this.selectedTheme = theme;
          StorageUtils.setTheme(theme);
        },
        newConversation() {
          if (this.isGenerating) return;
          this.viewingConvId = StorageUtils.getNewConvId();
          this.editingMsg = null;
          this.fetchMessages();
          chatScrollToBottom();
        },
        setViewingConv(convId) {
          if (this.isGenerating) return;
          this.viewingConvId = convId;
          this.editingMsg = null;
          this.fetchMessages();
          chatScrollToBottom();
        },
        deleteConv(convId) {
          if (this.isGenerating) return;
          if (window.confirm('Are you sure to delete this conversation?')) {
            StorageUtils.remove(convId);
            if (this.viewingConvId === convId) {
              this.viewingConvId = StorageUtils.getNewConvId();
              this.editingMsg = null;
            }
            this.fetchConversation();
            this.fetchMessages();
          }
        },
        async sendMessage() {
          if (!this.inputMsg) return;
          const currConvId = this.viewingConvId;

          StorageUtils.appendMsg(currConvId, {
            id: Date.now(),
            role: 'user',
            content: this.inputMsg,
          });
          this.fetchConversation();
          this.fetchMessages();
          this.inputMsg = '';
          this.editingMsg = null;
          this.generateMessage(currConvId);
          chatScrollToBottom();
        },
        async generateMessage(currConvId) {
          if (this.isGenerating) return;
          this.pendingMsg = { id: Date.now()+1, role: 'assistant', content: null };
          this.isGenerating = true;
          this.editingMsg = null;

          try {
            const abortController = new AbortController();
            this.stopGeneration = () => abortController.abort();
            const params = {
              messages: [
                { role: 'system', content: this.config.systemMessage },
                ...this.messages,
              ],
              stream: true,
              cache_prompt: true,
              temperature: this.config.temperature,
              top_k: this.config.top_k,
              top_p: this.config.top_p,
              max_tokens: this.config.max_tokens,
              ...(this.config.custom.length ? JSON.parse(this.config.custom) : {}),
              ...(this.config.apiKey ? { api_key: this.config.apiKey } : {}),
            };
            const config = {
              controller: abortController,
              api_url: BASE_URL,
              endpoint: '/chat/completions',
            };
            for await (const chunk of llama(prompt, params, config)) {
              const stop = chunk.data.stop;
              const addedContent = chunk.data.choices[0].delta.content;
              const lastContent = this.pendingMsg.content || '';
              if (addedContent) {
                this.pendingMsg = {
                  id: this.pendingMsg.id,
                  role: 'assistant',
                  content: lastContent + addedContent,
                };
              }
            }

            StorageUtils.appendMsg(currConvId, this.pendingMsg);
            this.fetchConversation();
            this.fetchMessages();
            setTimeout(() => document.getElementById('msg-input').focus(), 1);
          } catch (error) {
            if (error.name === 'AbortError') {
              // user stopped the generation via stopGeneration() function
              StorageUtils.appendMsg(currConvId, this.pendingMsg);
              this.fetchConversation();
              this.fetchMessages();
            } else {
              console.error(error);
              alert(error);
              // pop last user message
              const lastUserMsg = StorageUtils.popMsg(currConvId);
              this.inputMsg = lastUserMsg ? lastUserMsg.content : '';
            }
          }

          this.pendingMsg = null;
          this.isGenerating = false;
          this.stopGeneration = () => {};
          this.fetchMessages();
        },

        // message actions
        regenerateMsg(msg) {
          if (this.isGenerating) return;
          // TODO: somehow keep old history (like how ChatGPT has different "tree"). This can be done by adding "sub-conversations" with "subconv-" prefix, and new message will have a list of subconvIds
          const currConvId = this.viewingConvId;
          StorageUtils.filterAndKeepMsgs(currConvId, (m) => m.id < msg.id);
          this.fetchConversation();
          this.fetchMessages();
          this.generateMessage(currConvId);
        },
        copyMsg(msg) {
          navigator.clipboard.writeText(msg.content);
        },
        editUserMsgAndRegenerate(msg) {
          if (this.isGenerating) return;
          const currConvId = this.viewingConvId;
          const newContent = msg.content;
          this.editingMsg = null;
          StorageUtils.filterAndKeepMsgs(currConvId, (m) => m.id < msg.id);
          StorageUtils.appendMsg(currConvId, {
            id: Date.now(),
            role: 'user',
            content: newContent,
          });
          this.fetchConversation();
          this.fetchMessages();
          this.generateMessage(currConvId);
        },

        // settings dialog methods
        closeAndSaveConfigDialog() {
          try {
            if (this.config.custom.length) JSON.parse(this.config.custom);
          } catch (error) {
            alert('Invalid JSON for custom config. Please either fix it or leave it empty.');
            return;
          }
          for (const key of CONFIG_NUMERIC_KEYS) {
            if (isNaN(this.config[key]) || this.config[key].toString().trim().length === 0) {
              alert(`Invalid number for ${key} (expected an integer or a float)`);
              return;
            }
            this.config[key] = parseFloat(this.config[key]);
          }
          this.showConfigDialog = false;
          StorageUtils.setConfig(this.config);
        },
        closeAndDiscardConfigDialog() {
          this.showConfigDialog = false;
          this.config = StorageUtils.getConfig();
        },
        resetConfigDialog() {
          if (window.confirm('Are you sure to reset all settings?')) {
            this.config = {...CONFIG_DEFAULT};
          }
        },

        // sync state functions
        fetchConversation() {
          this.conversations = StorageUtils.getAllConversations();
        },
        fetchMessages() {
          this.messages = StorageUtils.getOneConversation(this.viewingConvId)?.messages ?? [];
        },
      },
    });
    mainApp.config.errorHandler = alert;
    try {
      mainApp.mount('#app');
    } catch (err) {
      console.error(err);
      document.getElementById('app').innerHTML = `<div style="margin:2em auto">
        Failed to start app. Please try clearing localStorage and try again.<br/>
        <br/>
        <button class="btn" onClick="localStorage.clear(); window.location.reload();">Clear localStorage</button>
      </div>`;
    }
  </script>
</body>

</html>
